import {
    BaseEvent,
    DisplayEvent,
    MetadataResponse,
    ObjectFinish,
    ObjectSizeInformation,
    Restart,
    WindowSize,
    ObjectInformation, DisplayZone, WindowInformation
} from "../event-types";
import {toRect, roots, containsRect} from "./query-helper";
import {displayZones} from "./display-zones";


export class DisplayPreparation {

    private readonly margin = 1;

    private normalize(input?: string): string {
        if (!input) {
            return '';
        }
        const firstInput = input.split('\n')[0];
        if (!firstInput) {
            return '';
        }
        return firstInput.replace('Standardbezeichnung:', '').replace('[True]', '').replace('[False]', '').replace('*', '').trim();
    }

    public prepareDisplayEvents(data: BaseEvent[], metadata: MetadataResponse): DisplayEvent[] {
        const result: DisplayEvent[] = [];
        const restartEvents = <Restart[]>data.filter(d => d.EventType === 'restart');
        const finishEvents = <ObjectFinish[]>data.filter(d => d.EventType === 'AdminEvent');
        const windowSizeEvents = <WindowSize[]>data.filter(d => d.EventType === 'LayoutEvent');
        const matchedEvents = restartEvents.map<MatchedEvent>(r => {
            const restartTimestamp = +r.Timestamp;
            const relatedFinishEvent = finishEvents.find(f => +f.Timestamp > restartTimestamp);
            if (!relatedFinishEvent) {
                throw new Error(`A related finish event at timestamp ${restartTimestamp} could not be found`);
            }
            const finishTimestamp = +relatedFinishEvent.Timestamp;
            const relatedWindowSizeEvent = windowSizeEvents.find(w => +w.Timestamp >= restartTimestamp && +w.Timestamp <= finishTimestamp);
            if (!relatedWindowSizeEvent) {
                throw new Error(`A related window size event between timestamps ${restartTimestamp} and ${finishTimestamp} could not be found`);
            }
            return {
                restart: r,
                finish: relatedFinishEvent,
                windowSize: relatedWindowSizeEvent
            };
        });
        for (let keyFrame of matchedEvents) {
            result.push(this.createDisplayEvent(keyFrame, data, metadata));
        }
        return result;
    }

    public contentTransformation(objectSizeEvents: ObjectSizeInformation[]) {
        return this.aggregateElements(this.cleanUp(objectSizeEvents), this.toObjectInformation(objectSizeEvents));
    }

    private createDisplayEvent(event: MatchedEvent, data: BaseEvent[], metadata: MetadataResponse): DisplayEvent {
        const beginTimestamp = +event.restart.Timestamp;
        const endTimestamp = +event.finish.Timestamp;
        const objectSizeEvents = <ObjectSizeInformation[]>data.filter(e => e.EventType === 'object-size' && +e.Timestamp >= beginTimestamp && +e.Timestamp <= endTimestamp);
        const windowInformation: WindowInformation = {
            width: event.windowSize.Event.Width,
            height: event.windowSize.Event.Height,
            timestamp: +event.windowSize.Timestamp,
        }
        const elements = this.contentTransformation(objectSizeEvents);
        const adjustedDisplayZones = this.adjustDisplayZones(displayZones.filter(d => d.isVisible(elements, windowInformation)), windowInformation)
        const displayEvent: DisplayEvent = {
            eventType: 'display-event',
            timestamp: beginTimestamp,
            window: windowInformation,
            elements: elements,
            zones: adjustedDisplayZones,
        };
        return this.addMetadata(displayEvent, this.toObjectInformation(objectSizeEvents), metadata);
    }

    private adjustDisplayZones(displayZones: DisplayZone[], windowSize: WindowInformation) {
        return displayZones.map(d => {
            const newDisplayZone = d.copy();
            const widthScale = windowSize.width / d.referenceWidth;
            const heightScale = windowSize.height / d.referenceHeight;
            newDisplayZone.x *= widthScale;
            newDisplayZone.y *= heightScale;
            newDisplayZone.width *= widthScale;
            newDisplayZone.height *= heightScale;
            return newDisplayZone;
        });
    }

    private toObjectInformation(objectSizeInformation: ObjectSizeInformation[]): ObjectInformation[] {
        return objectSizeInformation.map(o => {
            return {
                x: o.X,
                y: o.Y,
                width: o.Width,
                height: o.Height,
                enabled: o.Attributes?.Enabled,
                checked: o.Attributes?.Checked,
                label: this.normalize(o.Label)
            };
        });
    }

    private cleanUp(objectSizeInformation: ObjectSizeInformation[]): ObjectInformation[] {
        const initialSize = objectSizeInformation.length;
        const labelledElements = objectSizeInformation.filter(o => o.IsVisible && this.normalize(o.Label) !== '');
        const resultSize = labelledElements.length;
        console.debug(`Removed ${initialSize - resultSize} from key frame at ${objectSizeInformation[0]?.Timestamp ?? -1}`);
        return this.toObjectInformation(labelledElements);
    }

    private aggregateElements(objectInformation: ObjectInformation[], originalElements: ObjectInformation[]): ObjectInformation[] {
        const result: ObjectInformation[] = [];
        const originalRectangles = toRect(originalElements);
        // Find groups by label
        const groupedObjects = objectInformation.reduce((group, obj) => {
            const label = obj.label;
            group[label] = group[label] ?? [];
            group[label]!.push(obj);
            return group;
        }, <{ [key: string]: ObjectInformation[] }>{});
        // Aggregate elements, if their bounding box matches => there is a parent child relationship
        for (let group of Object.keys(groupedObjects)) {
            // Add it to our result set, if it is only one element
            if (groupedObjects[group]!.length === 1) {
                result.push(...groupedObjects[group]!);
                continue;
            }
            // If there are multiple we need to group by the bounding box
            const testGroup = groupedObjects[group]!;
            const rectangles = toRect(testGroup);
            const rootElements = roots(rectangles);
            for (let root of rootElements) {
                root.contains = originalRectangles.filter(t => containsRect(root.rect, t.rect));
            }
            for (let root of rootElements) {
                const enabledChildren = root.contains.map(e => e.entry)
                    .filter(e => e.enabled !== undefined);
                const checkedChildren = root.contains.map(e => e.entry)
                    .filter(e => e.checked !== undefined);
                root.entry.enabled = root.entry.enabled ??
                enabledChildren.length > 0 ? enabledChildren.some(e => e.enabled) : undefined;
                root.entry.checked = root.entry.checked ??
                checkedChildren.length > 0 ? checkedChildren.some(e => e.checked) : undefined;
            }
            result.push(...rootElements.map(r => r.entry));
        }
        return result;
    }

    private addMetadata(display: DisplayEvent, original: ObjectInformation[], metadata: MetadataResponse): DisplayEvent {
        this.readjustDisplayZones(display);
        // Measures
        this.addMeasures(display, metadata);
        // Dimensions & Hierarchies
        this.addDimensions(display, original, metadata);
        this.correctHierarchies(display, metadata);
        // Levels
        const diff = this.addLevels(display);
        // Members
        this.addMembers(display, diff ?? 0);
        this.addSelectedMembers(display);
        // Visualizations
        this.addVisualizations(display);

        const allowedLabels = ['Null-Werte ausblenden', 'Automatisch aktualisieren'];
        display.elements.forEach(e => {
            if(e.metadataType === undefined && !allowedLabels.includes(e.label)) {
                e.checked = undefined;
            }
        })
        return display;
    }

    private correctHierarchies(display: DisplayEvent, metadata: MetadataResponse) {
        const dimensionZone = display.zones.find(z => z.label.startsWith('Dimensions'));
        const hierarchies = metadata.dimensions.map(d => d.hierarchies).flat();
        display.elements.forEach(e => {
            if(hierarchies.find(h => e.label === h.name) && (!dimensionZone || !this.contains(e, dimensionZone))) {
                e.metadataType = 'hierarchy';
            }
        });
    }

    private readjustDisplayZones(display: DisplayEvent) {
        // visualisation
        const visualisationSelectionZone = display.zones.find(z => z.label === 'Visualisation Selection');
        const visualisationElement = display.elements.find(e => e.label === 'VISUALISIERUNGEN');
        const formatElement = display.elements.find(e => e.label === 'FORMATIERUNG');
        if(visualisationSelectionZone && visualisationElement && formatElement) {
            visualisationSelectionZone.y = visualisationElement.y + visualisationElement.height + 1;
            visualisationSelectionZone.height = formatElement.y - visualisationSelectionZone.y - 1;
        }
        // selection
        const selectionZone = display.zones.find(z => z.label === 'Selection');
        const nullValueElement = display.elements.find(e => e.label.toLowerCase().startsWith('null-werte'));
        const visualisationZone = display.zones.find(z => z.label === 'Visualisation');
        if(selectionZone && nullValueElement && visualisationZone  && visualisationSelectionZone) {
            selectionZone.height = nullValueElement.y - selectionZone.y - 1;
            selectionZone.x = nullValueElement.x + 5;
            visualisationZone.x = selectionZone.x + selectionZone.width + 1;
            visualisationZone.width = visualisationSelectionZone.x - visualisationZone.x - 1;
        }
        // dimension
        const dimensionZone = display.zones.find(z => z.label.startsWith('Dimensions'));
        const searchField = display.elements.find(e => e.label === 'Suchen...');
        if (dimensionZone && searchField && selectionZone) {
            dimensionZone.width = selectionZone.x - dimensionZone.x - 1;
            dimensionZone.height = searchField.y - dimensionZone.y - 1;
        }
        // level & member
        const levelZone = display.zones.find(z => z.label.startsWith('Levels'));
        const memberZone = display.zones.find(z => z.label.startsWith('Members'));
        const workbookManageElement = display.elements.find(e => e.label === 'Arbeitsmappe verwalten');
        if(levelZone && memberZone && workbookManageElement) {
            levelZone.height = workbookManageElement.y - levelZone.y - 1;
            memberZone.height = workbookManageElement.y - memberZone.y - 1;
        }
        const memberSelectionZone = display.zones.find(z => z.label === 'Member Selection');
        const deleteElement = display.elements.find(e => e.label === 'Löschen');
        if(memberSelectionZone && workbookManageElement && deleteElement) {
            memberSelectionZone.height = workbookManageElement.y - memberSelectionZone.y - 1;
            memberSelectionZone.x = deleteElement.x;
            memberSelectionZone.width = workbookManageElement.x + workbookManageElement.width - memberSelectionZone.x;
        }
        // measures
        const measureZone = display.zones.find(z => z.label.startsWith('Measures'));
        if(measureZone && workbookManageElement) {
            measureZone.height = workbookManageElement.y - measureZone.y - 1;
        }
    }

    private addSelectedMembers(display: DisplayEvent) {
        const zone = display.zones.find(e => e.label === 'Member Selection');
        if (!zone) {
            return;
        }
        display.elements.filter(e => this.contains(e, zone)).forEach(e => {
            e.metadataType = 'member';
        })
    }

    private addVisualizations(display: DisplayEvent) {
        const zone = display.zones.find(z => z.label === 'Visualisation Selection');
        if (!zone) {
            // Measure Zone is not visible
            return;
        }

        display.elements
            .filter(e => this.contains(e, zone))
            .forEach(e => {
                e.metadataType = 'visualisation';
            });
    }

    private addMembers(display: DisplayEvent, diff: number) {
        const zone = display.zones.find(z => z.label === 'Members');
        if (!zone) {
            // Measure Zone is not visible
            return;
        }
        const lowerBound = display.elements.find(e => e.label.startsWith('Absteigend sortieren'))!;

        const diffY = lowerBound.y + lowerBound.height - zone.y;
        zone.y = lowerBound.y + lowerBound.height + 1;
        zone.height -= diffY + 1;
        for(let box of display.elements) {
            if(box.x - zone.x > 20 && this.intersect(box, zone)) {
                box.x -= diff;
            }
        }

        // Get box reference based on the measures inside the display zone and look on the y-axis for similar boxes
        const reference = display.elements.find(e => this.contains(e, zone));
        if (!reference) {
            // No reference element found
            return;
        }
        // all similar boxes on the y-axis that are also part of the measure display zone
        const similarBoxes = display.elements.filter(e => Math.abs(e.width - reference.width) < this.margin
            && Math.abs(e.x - reference.x) < this.margin);
        similarBoxes.forEach(b => {
            b.metadataType = 'member';
        });
        // Remove all parts of the measure display that are not inside the measure display zone
        for (let outOfBoundsBox of similarBoxes.filter(b => !this.contains(b, zone) && !this.intersect(b, zone))) {
            const ooBIndex = display.elements.indexOf(outOfBoundsBox);
            delete display.elements[ooBIndex];
        }
        // Adjust all boxes that intersect with the measure display zone to only by in bounds of that zone
        for (let intersectBox of similarBoxes.filter(b => this.intersect(b, zone) && !this.contains(b, zone))) {
            const oldY = intersectBox.y;
            intersectBox.x = Math.max(intersectBox.x, zone.x);
            intersectBox.y = Math.max(intersectBox.y, zone.y);
            if(oldY === intersectBox.y) {
                intersectBox.height = zone.y + zone.height - intersectBox.y;
            } else {
                intersectBox.height = intersectBox.height - (intersectBox.y - oldY);
            }
        }

        display.elements.filter(e => this.contains(e, zone)).forEach(e => {
            if(e.metadataType === 'folder') {
                e.checked = undefined;
            } else if(e.metadataType === 'member') {
                e.checked = e.checked ?? false;
            }
        })

        display.elements = display.elements.filter(e => e !== undefined);
    }
    private addLevels(display: DisplayEvent) {

        const zone = display.zones.find(z => z.label === 'Levels');
        if (!zone) {
            // Measure Zone is not visible
            return;
        }
        const lowerBound = display.elements.find(e => e.label.startsWith('Absteigend sortieren'))!;

        const diffY = lowerBound.y + lowerBound.height - zone.y;
        zone.y = lowerBound.y + lowerBound.height + 1;
        zone.height -= diffY + 1;
        let diff = 0;
        for(let box of display.elements) {
            if(box.x > 20 && this.intersect(box, zone)) {
                diff = box.x - 6;
                box.x = 6;
            }
        }

        // Get box reference based on the measures inside the display zone and look on the y-axis for similar boxes
        const reference = display.elements.find(e => this.contains(e, zone));
        if (!reference) {
            // No reference element found
            return;
        }
        // all similar boxes on the y-axis that are also part of the measure display zone
        const similarBoxes = display.elements.filter(e => Math.abs(e.width - reference.width) < this.margin
            && Math.abs(e.x - reference.x) < this.margin);
        similarBoxes.forEach(b => {
            b.metadataType = 'level';
        });
        // Remove all parts of the measure display that are not inside the measure display zone
        for (let outOfBoundsBox of similarBoxes.filter(b => !this.contains(b, zone) && !this.intersect(b, zone))) {
            const ooBIndex = display.elements.indexOf(outOfBoundsBox);
            delete display.elements[ooBIndex];
        }
        // Adjust all boxes that intersect with the measure display zone to only by in bounds of that zone
        for (let intersectBox of similarBoxes.filter(b => this.intersect(b, zone) && !this.contains(b, zone))) {
            const oldY = intersectBox.y;
            intersectBox.x = Math.max(intersectBox.x, zone.x);
            intersectBox.y = Math.max(intersectBox.y, zone.y);
            if(oldY === intersectBox.y) {
                intersectBox.height = zone.y + zone.height - intersectBox.y;
            } else {
                intersectBox.height = intersectBox.height - (intersectBox.y - oldY);
            }
        }

        display.elements.filter(e => this.contains(e, zone)).forEach(e => {
            if(e.metadataType === 'folder') {
                e.checked = undefined;
            } else if(e.metadataType === 'level') {
                e.checked = e.checked ?? false;
            }
        })

        display.elements = display.elements.filter(e => e !== undefined);

        return diff;
    }

    private addMeasures(displayEvent: DisplayEvent, metadata: MetadataResponse) {
        // All elements in the UI that are measures by their label (Selection Zone as well as Measure Zone)
        displayEvent.elements.forEach(e => {
            const measure = metadata.measures.find(m => m.name === e.label);
            if (measure) {
                e.metadataType = 'measure';
            }
        });
        const zone = displayEvent.zones.find(z => z.label === 'Measures');
        if (!zone) {
            // Measure Zone is not visible
            return;
        }

        const lowerBound = displayEvent.elements.find(e => e.label.startsWith('Suchen'));
        if(!lowerBound) {
            return;
        }
        const diffY = lowerBound.y + lowerBound.height - zone.y;
        zone.y = lowerBound.y + lowerBound.height + 1;
        zone.height -= diffY + 1;

        for(let box of displayEvent.elements) {
            if(box.x > 20 && this.intersect(box, zone)) {
                box.x = 6;
            }
        }

        // Get box reference based on the measures inside the display zone and look on the y-axis for similar boxes
        const reference = displayEvent.elements.find(e => e.metadataType === 'measure' && this.contains(e, zone));
        if (!reference) {
            // No reference element found
            return;
        }
        // all similar boxes on the y-axis that are also part of the measure display zone
        const similarBoxes = displayEvent.elements.filter(e => Math.abs(e.width - reference.width) < this.margin
            && Math.abs(e.x - reference.x) < this.margin);
        similarBoxes.forEach(b => {
            if(b.metadataType !== 'measure') {
                b.metadataType = 'folder';
            }
        });
        // Remove all parts of the measure display that are not inside the measure display zone
        for (let outOfBoundsBox of similarBoxes.filter(b => !this.contains(b, zone) && !this.intersect(b, zone))) {
            const ooBIndex = displayEvent.elements.indexOf(outOfBoundsBox);
            delete displayEvent.elements[ooBIndex];
        }
        // Adjust all boxes that intersect with the measure display zone to only by in bounds of that zone
        for (let intersectBox of similarBoxes.filter(b => this.intersect(b, zone) && !this.contains(b, zone))) {
            const oldY = intersectBox.y;
            intersectBox.x = Math.max(intersectBox.x, zone.x);
            intersectBox.y = Math.max(intersectBox.y, zone.y);
            if(oldY === intersectBox.y) {
                intersectBox.height = zone.y + zone.height - intersectBox.y;
            } else {
                intersectBox.height = intersectBox.height - (intersectBox.y - oldY);
            }
        }

        displayEvent.elements.filter(e => this.contains(e, zone)).forEach(e => {
            if(e.metadataType === 'folder') {
                e.checked = undefined;
            } else if(e.metadataType === 'measure') {
                e.checked = e.checked ?? false;
            }
        })

        displayEvent.elements = displayEvent.elements.filter(e => e !== undefined);
    }

    private addDimensions(displayEvent: DisplayEvent, original: ObjectInformation[], metadata: MetadataResponse) {
        // All elements in the UI that are measures by their label (Selection Zone as well as Measure Zone)
        displayEvent.elements.forEach(e => {
            const dimension = metadata.dimensions.find(m => m.name === e.label);
            if (dimension) {
                e.metadataType = 'dimension';
            }
        });
        const hierarchies = metadata.dimensions.map(d => d.hierarchies).flat();
        displayEvent.elements.forEach(e => {
            const hierarchy = hierarchies.find(m => m.name === e.label);
            if (hierarchy && !e.metadataType) {
                e.metadataType = 'hierarchy';
            }
        });
        const zone = displayEvent.zones.find(z => z.label.startsWith('Dimensions'));
        if (!zone) {
            // Dimension Zone is not visible
            return;
        }

        const lowerBound = displayEvent.elements.find(e => e.label.startsWith('Dimensionen'))!;
        const diffY = lowerBound.y + lowerBound.height - zone.y;
        zone.y = lowerBound.y + lowerBound.height + 1;
        zone.height -= diffY + 1;

        for(let box of displayEvent.elements) {
            if(box.x > 20 && this.intersect(box, zone)) {
                box.x = 6;
            }
        }

        // Get box reference based on the measures inside the display zone and look on the y-axis for similar boxes
        const reference = displayEvent.elements.find(e => e.metadataType === 'dimension' && this.intersect(e, zone));
        if (!reference) {
            // No reference element found
            return;
        }
        // all similar boxes on the y-axis that are also part of the measure display zone
        const similarBoxes = displayEvent.elements.filter(e => Math.abs(e.width - reference.width) < this.margin
            && Math.abs(e.x - reference.x) < this.margin);
        similarBoxes.forEach(b => {
            if(b.metadataType !== 'dimension' && b.metadataType !== 'hierarchy') {
                b.metadataType = 'folder';
            }
        });

        similarBoxes.forEach(b => {
            if(b.x > zone.x && b.y > zone.y && b.y + b.height < zone.y + zone.height && b.x < zone.x + zone.width && b.x + b.width > zone.x + zone.width) {
                b.width = zone.x + zone.width - b.x - 1;
            }
        });

        // Remove all parts of the measure display that are not inside the measure display zone
        for (let outOfBoundsBox of similarBoxes.filter(b => !this.contains(b, zone) && !this.intersect(b, zone))) {
            const ooBIndex = displayEvent.elements.indexOf(outOfBoundsBox);
            delete displayEvent.elements[ooBIndex];
        }
        // Adjust all boxes that intersect with the measure display zone to only by in bounds of that zone
        for (let intersectBox of similarBoxes.filter(b => this.intersect(b, zone) && !this.contains(b, zone))) {
            const oldY = intersectBox.y;
            intersectBox.x = Math.max(intersectBox.x, zone.x);
            intersectBox.y = Math.max(intersectBox.y, zone.y);
            if (oldY === intersectBox.y) {
                intersectBox.height = zone.y + zone.height - intersectBox.y;
            } else {
                intersectBox.height = intersectBox.height - (intersectBox.y - oldY);
            }
            if(intersectBox.x + intersectBox.width > zone.x + zone.width) {
                intersectBox.width = zone.x + zone.width - intersectBox.x - 1;
            }
        }

        for (let openedDimensionBox of similarBoxes.filter(b => b.metadataType === 'dimension' && b.height > 30)) {
            const hierarchies = metadata.dimensions.find(d => d.name === openedDimensionBox.label)?.hierarchies;
            if(!hierarchies) {
                continue;
            }
            const hierarchyBoxes = original
                .filter(o => o.y >= openedDimensionBox.y && o.y + o.height <= openedDimensionBox.y + openedDimensionBox.height && o.label === openedDimensionBox.label)
                .filter(o => o.y - openedDimensionBox.y > 20 && Math.abs(o.x - openedDimensionBox.x) < 10)
            hierarchyBoxes.forEach(h => h.metadataType = 'hierarchy');
            displayEvent.elements.push(...hierarchyBoxes);
        }

        displayEvent.elements.filter(e => this.contains(e, zone)).forEach(e => {
            if(e.metadataType === 'folder') {
                e.checked = undefined;
            }
        })

        displayEvent.elements = displayEvent.elements.filter(e => e !== undefined);
    }

    private contains(element: ObjectInformation, zone: DisplayZone) {
        return element.x >= zone.x && element.x + element.width <= zone.x + zone.width &&
            element.y >= zone.y && element.y + element.height <= zone.y + zone.height;
    }

    private intersect(element: ObjectInformation, zone: DisplayZone) {
        const upperLeft = { x: element.x, y: element.y };
        const upperRight = { x: element.x + element.width, y: element.y };
        const lowerLeft = { x: element.x, y: element.y + element.height };
        const lowerRight = { x: element.x + element.width, y: element.y + element.height };
        return this.pointInZone(upperLeft, zone) || this.pointInZone(upperRight, zone) ||
            this.pointInZone(lowerLeft, zone) || this.pointInZone(lowerRight, zone);
    }

    private pointInZone(point: {x: number, y: number}, zone: DisplayZone) {
        return point.x >= zone.x && point.x <= zone.x + zone.width &&
            point.y >= zone.y && point.y <= zone.y + zone.height;
    }
}

interface MatchedEvent {
    restart: Restart;
    finish: ObjectFinish;
    windowSize: WindowSize;
}